Backtesting Dual moving average strategy on multiple assets using vectorbt¶

Reference: # http://qubitquants.pro/multi_asset_portfolio_simulation/index.html

In this tutorial, we will talk about Multi Asset Portfolio Simulation, beginning with:

Running Multi-asset Portfolio Backtesting simulation using vbt.Portfolio.from_signals() like:

  1. Unified Portfolio Simulation
  2. Asset-wise Discrete Portfolio Simulation
  3. Grouped Portfolio Simulation

1) Unified Portfolio Simulation¶

In [9]:
import vectorbt as vbt
import numpy
import pandas
import warnings
from plotly.offline import init_notebook_mode
import plotly.io as pio

init_notebook_mode()
pio.renderers.default = 'notebook'

warnings.filterwarnings("ignore")
In [10]:
symbols = ["MSFT","AAPL","GOOGL"]
close_price = vbt.YFData.download(symbols, interval="1d",
                        missing_index="drop",
                        start="2020-01-01").get("Close")
print(close_price)
symbol                           MSFT        AAPL       GOOGL
Date                                                         
2019-12-31 05:00:00+00:00  152.596512   71.711731   66.969498
2020-01-02 05:00:00+00:00  155.422028   73.347923   68.433998
2020-01-03 05:00:00+00:00  153.486771   72.634865   68.075996
2020-01-06 05:00:00+00:00  153.883514   73.213631   69.890503
2020-01-07 05:00:00+00:00  152.480408   72.869293   69.755501
...                               ...         ...         ...
2023-07-31 04:00:00+00:00  335.920013  196.449997  132.720001
2023-08-01 04:00:00+00:00  336.339996  195.610001  131.550003
2023-08-02 04:00:00+00:00  327.500000  192.580002  128.380005
2023-08-03 04:00:00+00:00  326.660004  191.169998  128.449997
2023-08-04 04:00:00+00:00  327.779999  181.990005  128.110001

[905 rows x 3 columns]
In [11]:
"""
Setup entry and exit condition, which is with moving average (MA) 
crossover combination of fast MA of 9 days and slow MA of 17 days
"""

SMA_9 = vbt.MA.run(close_price, window=9)
SMA_17 = vbt.MA.run(close_price, window=17)

entries = SMA_9.ma_crossed_above(SMA_17)
exits = SMA_9.ma_crossed_below(SMA_17)
Description of a few Parameter settings for vbt.Portfolio.from_signals()¶

We will see a short description of the new parameters of vbt.Portfolio.from_signals()

a.) size : Specifies the position size in units. For any fixed size, you can set to any number to buy/sell some fixed amount or value. For any target size, you can set to any number to buy/sell an amount relative to the current position or value. If you set this to np.nan or 0 it will get skipped (or close the current position in the case of setting 0 for any target size). Set to np.inf to buy for all cash, or -np.inf to sell for all free cash. A point to remember setting to np.inf may cause the scenario for the portfolio simulation to become heavily weighted to one single instrument. So use a sensible size related.

b.) size_type: Choose units to be used for the size. In this tutorial, we use percent and for other parameter like amount, value, TargetValue,TargetAmount, please refer here: https://vectorbt.dev/api/portfolio/enums/#vectorbt.portfolio.enums.SizeType for more explanation.

b.) init_cash : Initial capital per column (or per group with cash sharing). By setting it to auto the initial capital is automatically decided based on the position size you specify in the above size parameter.

c.) cash_sharing : Accepts a boolean (True or False) value to specify whether cash sharing is to be disabled or if enabled then cash is shared across all the assets in the portfolio or cash is shared within the same group. If group_by is None and cash_sharing is True, group_by becomes True to form a single group with cash sharing. Example: Consider three columns (3 assets), each having $100 of starting capital. If we built one group of two columns and one group of one column, the init_cash would be np.array([200, 100]) with cash sharing enabled and np.array([100, 100, 100]) without cash sharing.

d.) call_seq : Default sequence of calls per row and group. Controls the sequence in which order_func_nb is executed within each segment. For more details of this function kindly refer the documentation.

e.) group_by : can be boolean, integer, string, or sequence to call multi-level indexing and can accept both level names and level positions. In this tutorial I will be setting group_by = True to treat the entire portfolio simulation in a unified manner for all assets in congruence with cash_sharing = True. When I want to create custom groups with specific symbols in each group then I will be setting group_by = 0 to specify the level position (in multi-index levels) as the first in the hierarchy.``

In [12]:
"""In this section, we run the portfolio simulation treating the entire portfolio as a singular asset by enabling the following parameters in the pf.from_signals():

cash_sharing = True
group_by = True
call_seq = "auto"
size = 1000
"""
unified_portfolio = vbt.Portfolio.from_signals(close_price, 
                           entries,
                           exits,
                           init_cash=100000, # in $
                           fees=0.0025, # in %
                           slippage=0.0025, # in %
                           freq="1D",
                           direction="LongOnly",
                           group_by=True,
                           cash_sharing=True,
                           call_seq="auto",
                           size_type="value",
                           size=10000) # 

unified_portfolio.stats()
Out[12]:
Start                          2019-12-31 05:00:00+00:00
End                            2023-08-04 04:00:00+00:00
Period                                 905 days 00:00:00
Start Value                                     100000.0
End Value                                  114244.371909
Total Return [%]                               14.244372
Benchmark Return [%]                          119.959258
Max Gross Exposure [%]                          37.04273
Total Fees Paid                              3849.043592
Max Drawdown [%]                                9.599631
Max Drawdown Duration                  410 days 00:00:00
Total Trades                                          77
Total Closed Trades                                   75
Total Open Trades                                      2
Open Trade PnL                               2225.853808
Win Rate [%]                                   41.333333
Best Trade [%]                                 53.523193
Worst Trade [%]                               -15.414574
Avg Winning Trade [%]                          10.222951
Avg Losing Trade [%]                           -4.477864
Avg Winning Trade Duration    32 days 02:19:21.290322580
Avg Losing Trade Duration               10 days 18:00:00
Profit Factor                                   1.608475
Expectancy                                    160.246908
Sharpe Ratio                                    0.832017
Calmar Ratio                                    0.574791
Omega Ratio                                     1.158445
Sortino Ratio                                   1.223773
Name: group, dtype: object
In [13]:
unified_portfolio.plot(group_by=True, subplots=["cum_returns","cash","value"]).show()

Asset-wise Discrete Portfolio Simulation¶

In this section, we will see how to run the portfolio simulation for each asset in the portfolio independently.

In [14]:
discrete_portfolio = vbt.Portfolio.from_signals(close_price, 
                           entries,
                           exits,
                           init_cash=100000, # in $
                           fees=0.0025, # in %
                           slippage=0.0025, # in %
                           direction="LongOnly",
                           freq="1D",
                           group_by=False,
                           call_seq="auto",
                           size_type="value",
                           size=10000) # For each trades, limit the position size in $
In [15]:
# trade each assets with start value of 100,000 and apply the trading strategy respectively to see which compa

stats_df = pandas.concat([unified_portfolio.stats()] + 
                         [discrete_portfolio[symbol].stats() for symbol 
                          in discrete_portfolio.wrapper.columns], axis = 1)
stats_df.rename(inplace = True, columns = {'group':'unified portfolio'})  
stats_df
Out[15]:
unified portfolio (9, 17, MSFT) (9, 17, AAPL) (9, 17, GOOGL)
Start 2019-12-31 05:00:00+00:00 2019-12-31 05:00:00+00:00 2019-12-31 05:00:00+00:00 2019-12-31 05:00:00+00:00
End 2023-08-04 04:00:00+00:00 2023-08-04 04:00:00+00:00 2023-08-04 04:00:00+00:00 2023-08-04 04:00:00+00:00
Period 905 days 00:00:00 905 days 00:00:00 905 days 00:00:00 905 days 00:00:00
Start Value 100000.0 100000.0 100000.0 100000.0
End Value 114244.371909 100464.286929 109144.818863 104635.266117
Total Return [%] 14.244372 0.464287 9.144819 4.635266
Benchmark Return [%] 119.959258 114.801764 153.779965 91.296045
Max Gross Exposure [%] 37.04273 13.156697 15.374071 13.08177
Total Fees Paid 3849.043592 1408.18117 1098.078495 1342.783926
Max Drawdown [%] 9.599631 5.323669 2.423966 4.499051
Max Drawdown Duration 410 days 00:00:00 427 days 00:00:00 319 days 00:00:00 428 days 00:00:00
Total Trades 77 28 22 27
Total Closed Trades 75 28 21 26
Total Open Trades 2 0 1 1
Open Trade PnL 2225.853808 0.0 2061.436761 164.417047
Win Rate [%] 41.333333 39.285714 42.857143 42.307692
Best Trade [%] 53.523193 27.570619 53.523193 31.516836
Worst Trade [%] -15.414574 -15.414574 -9.613316 -9.47488
Avg Winning Trade [%] 10.222951 7.22285 13.122513 10.850683
Avg Losing Trade [%] -4.477864 -4.40118 -3.953787 -4.984034
Avg Winning Trade Duration 32 days 02:19:21.290322580 32 days 02:10:54.545454545 36 days 21:19:59.999999999 28 days 04:21:49.090909091
Avg Losing Trade Duration 10 days 18:00:00 10 days 05:38:49.411764705 8 days 22:00:00 12 days 19:12:00
Profit Factor 1.608475 1.061899 2.48923 1.596531
Expectancy 160.246908 16.581676 337.30391 171.955733
Sharpe Ratio 0.832017 0.083773 1.274723 0.628242
Calmar Ratio 0.574791 0.035125 1.481968 0.409917
Omega Ratio 1.158445 1.015515 1.263997 1.129641
Sortino Ratio 1.223773 0.117409 1.947054 0.939655

(Optional) Additional work - just to check trading orders & PnL of AAPL¶

In [16]:
discrete_portfolio[(9, 17, "AAPL")].plot().show()